Advanced collections, shared state strategies, and data structure choices in .NET systems
When people first learn collections in .NET, they usually learn them as API surface area: List<T>, Dictionary<TKey,TValue>, HashSet<T>, Queue<T>, maybe ObservableCollection<T>, maybe a few concurrent collections.
In real production systems, that is not how senior engineers think.
They think in terms of access pattern, ownership, mutation frequency, concurrency model, memory growth, and read model shape.
That matters a lot in long-running desktop systems. In a WPF app controlling real hardware, bad collection design does not just make code a bit ugly. It creates dropped updates, UI freezes, memory growth, race conditions, inconsistent operator views, and systems that become unstable after running for hours or days.
So this topic is really not “which collection is fastest.” It is about how data moves through the system, who owns it, who is allowed to mutate it, how readers consume it safely, and how you stop the whole thing from collapsing under live load.
Part 1 — Big picture
Why data structure choices matter so much in real systems
In real-time and hardware-integrated systems, collections are not passive containers. They become part of the system’s behavior.
A defect stream is a collection problem. A machine status registry is a collection problem. A live alarm list is a collection problem. A recent telemetry history view is a collection problem. A summary dashboard is a collection problem.
If you choose the wrong structure, you often get one of four classes of failure:
- Performance failure: too much scanning, copying, locking, or allocating
- Correctness failure: inconsistent state, lost updates, duplicated entries
- Concurrency failure: race conditions, torn workflows, unpredictable behavior
- UI failure: too many notifications, thread violations, rendering lag
A lot of engineers underestimate this because individual operations look cheap in isolation. A List<T>.Add is cheap. A dictionary lookup is cheap. A collection changed notification is cheap.
But under real load, repeated thousands of times per second, inside a live UI app with multiple threads, “cheap” stops being cheap.
Why the wrong collection or shared-state design creates production bugs
The bigger issue is that collections often encode hidden assumptions.
If you use a List<Defect> for everything, you are quietly assuming:
- sequential traversal is acceptable
- duplicate handling is not important
- lookups are rare
- mutation is simple
- readers and writers are coordinated
- memory growth is acceptable
Those assumptions are often false.
For example, in a wafer inspection machine:
- results may arrive continuously
- operators may filter by defect class
- another screen may need lookup by defect ID
- a summary panel may need counts by class
- a review panel may need latest defects first
- a background saver may persist to disk or database
- the UI thread must remain responsive
One collection rarely serves all of those needs well.
Collection design is not only about speed
Senior engineers do not choose a collection just by asking, “What is fastest?”
They ask:
- Who writes to it?
- Who reads from it?
- How often does it change?
- Do readers need ordering?
- Do readers need lookup?
- Is duplication allowed?
- Does the UI bind to it?
- Can it grow forever?
- Is it shared across threads?
- Can we tolerate stale reads?
- Do we need snapshots or live mutation?
That is why collection choice is deeply tied to system design.
Part 2 — Thinking beyond “just use List<T>”
List<T>, Dictionary<TKey,TValue>, Queue<T>, and HashSet<T> are the foundation. That is fine. The problem is when engineers use them by habit instead of by workload.
Access patterns matter more than habit
The first question is not “Which collection do I know best?” The first question is “How will this data actually be used?”
For example:
- Append-heavy: incoming results, event stream, log buffer
- Lookup-heavy: machine state by subsystem, defect by ID
- Order-sensitive: recent alarms, time-series history, processing sequence
- Uniqueness-sensitive: deduping events, known active IDs
- UI-bound: operator-facing list, alarm grid, review panel
- Concurrency-sensitive: producer-consumer pipelines, background processing
One collection might be great for one dimension and terrible for another.
Practical selection thinking
Append-heavy usage
If data mostly arrives and is processed in order, a queue or append-oriented list works naturally.
Examples:
- incoming result messages
- telemetry sample ingestion
- background processing pipeline stages
You care about:
- cheap enqueue/add
- predictable flow
- bounded growth
- minimal contention
Lookup-heavy usage
If the main operation is “find by key,” you almost always want a dictionary.
Examples:
- machine state by device ID
- defect lookup by defect ID
- cached recipe by recipe name
- subsystem registry by logical name
Scanning a list repeatedly is a classic production mistake. It often starts small and becomes a hidden hot path.
Ordering needs
If users care about “latest first,” “by timestamp,” “by sequence,” or “display order,” then ordering is part of the problem.
Examples:
- most recent alarms first
- defect arrival order
- machine event timeline
- sorted regions or grouped views
Sometimes you keep insertion order in one structure and add indexes separately.
Thread-safety needs
If multiple threads touch the same structure, you must think beyond the collection type.
A non-thread-safe collection under concurrent mutation is dangerous. But a thread-safe collection alone still does not guarantee correctness. More on that later.
UI binding needs
WPF collection binding is a special case. The best internal processing collection is often not the best UI collection.
Internal structures should optimize ingestion and processing. UI structures should optimize stable rendering and controlled notification.
These are different jobs.
Memory pressure
Long-running systems must assume memory growth matters.
A collection that “works” during a five-minute test may fail after eight hours because:
- it keeps all history forever
- it duplicates large objects
- it publishes too many snapshots
- it retains references that prevent GC
- it stores full payloads when summaries would do
Memory design is part of collection design.
Part 3 — Real problems in a WPF desktop app controlling a wafer inspection machine
Let’s use a concrete system.
Imagine a WPF app that:
- controls inspection runs
- receives defects continuously from machine or image processing pipeline
- shows live thumbnails and defect lists
- tracks machine status and alarms
- persists data
- shows summaries by defect type, region, and severity
Maintaining a live defect list while new defects keep arriving
A naive approach is to bind ObservableCollection<Defect> directly to the incoming stream and add each defect as soon as it arrives.
This often looks fine in development. Then production load hits.
What goes wrong:
- thousands of UI notifications
- layout churn
- dispatcher backlog
- laggy scrolling
- operator interaction becomes sluggish
- background threads try to update UI-bound collection incorrectly
- memory grows because old items are never removed
The issue is not just the collection type. The issue is that ingestion structure and UI structure were incorrectly collapsed into one thing.
Fast lookup by ID, region, or classification
If you only keep a List<Defect>, then:
- lookup by ID becomes repeated linear scans
- counting by class becomes repeated grouping
- filtering by region becomes repeated rescans
- summary view becomes expensive under growth
This becomes especially painful when multiple screens ask different questions about the same live data.
A more realistic design may maintain:
- ordered raw defect stream
- dictionary by defect ID
- summary counters by defect class
- grouped or indexed views for region or review workflows
Concurrent machine state updates from multiple threads
Hardware callbacks, background polling loops, and command completion events may all try to update shared machine state.
A naive design:
- multiple threads mutate shared dictionary
- UI reads from it directly
- background services update different pieces independently
- no clear ownership
This creates subtle bugs:
- impossible state combinations
- stale reads
- partial updates
- alarm panel says one thing while status panel says another
- “device busy” and “device idle” appear in quick contradiction
Even if you use ConcurrentDictionary, you can still get inconsistent workflow-level state if multiple keys must change together.
UI binding directly to rapidly changing data
WPF is not a real-time rendering engine for arbitrary collection churn.
If every tiny change becomes:
- collection notification
- item notification
- layout update
- virtualization decision
- template creation
- rendering work
then the UI thread becomes a bottleneck.
A better design usually:
- collects hot changes internally
- batches them
- publishes snapshots or deltas at controlled intervals
- keeps UI collections smaller and more intentional
Keeping recent telemetry/history without unbounded memory growth
This is a classic long-running system problem.
Telemetry, alarm history, event history, defect preview history, command history — if you keep everything in memory forever, the system slowly degrades.
A senior engineer asks early:
- do we need all history in memory?
- do we need raw data or summaries?
- what is the retention window?
- what belongs in memory versus persistence?
- what is the operator actually looking at?
Coordinating live data, persisted data, and summary views
Production systems rarely have just one truth shape.
You may have:
- incoming live event stream
- persisted database records
- in-memory indexes
- current run summary
- UI projections
- recent-history rolling window
The hard part is not building each one. The hard part is keeping them consistent without turning the system into lock-heavy spaghetti.
Part 4 — Core collection choices in practice
List<T>
What workload it fits
List<T> is excellent when:
- you append often
- you enumerate sequentially
- indexed access matters
- mutation is mostly at the end
- thread-sharing is limited or controlled
Where it appears in real systems
- staged batch of new defects before publishing to UI
- temporary result buffers
- ordered command history for current operation
- snapshot payload generation
Common mistakes
- using it for repeated lookups by ID
- exposing it publicly for mutation
- sharing it across threads
- removing frequently from the front
- assuming it is fine forever as it grows without bounds
A List<T> is great as a local working set. It is often bad as a global shared live registry.
Dictionary<TKey,TValue>
What workload it fits
Use a dictionary when:
- you need fast lookup by key
- uniqueness by key matters
- you model registries, caches, indexes, state maps
Where it appears
- defect by ID
- subsystem state by subsystem name
- recipe cache by recipe ID
- active alarm by alarm code
- current device connections by endpoint
Common mistakes
- forgetting that dictionary enumeration order is not the design contract you should rely on
- assuming thread-safe reads/writes without protection
- mutating value objects inside it from many threads
- using dictionary as full system state without ownership rules
In real systems, a dictionary often represents the current indexed state, not the full ordered history.
HashSet<T>
What workload it fits
Use a hash set when uniqueness or membership testing matters.
Where it appears
- deduping recent event IDs
- tracking active alarms
- remembering processed result IDs
- known connected devices
- defect IDs already published to a given downstream system
Common mistakes
- using it when ordering matters
- relying on it for display
- forgetting to evict old entries in long-running systems
- sharing it unsafely under concurrency
A HashSet<T> is often a supporting structure, not the main business collection.
Queue<T>
What workload it fits
Use a queue when the model is naturally FIFO.
Where it appears
- incoming result pipeline
- outbound persistence work
- UI update batching
- command completion events awaiting processing
- recent-event buffer when combined with capped behavior
Common mistakes
- using plain queue with multiple producers/consumers unsafely
- allowing it to grow forever
- assuming it solves backpressure automatically
- using queue when random access or lookup is needed
Queues are about flow, not rich querying.
LinkedList<T>
If relevant
In most modern .NET business and desktop systems, LinkedList<T> is much less commonly the right answer than people think.
It can help when:
- you need stable node references
- insertion/removal in the middle is frequent
- traversal pattern really matches it
But in many real systems, it is not worth the complexity and poorer locality. A list, queue, or custom ring buffer is usually more practical.
Common mistakes
- choosing it because insert/remove is theoretically cheap
- ignoring that finding the node may still be the expensive part
- making code harder to reason about for little gain
Most senior engineers reach for it rarely.
ReadOnlyCollection<T> / IReadOnlyList<T>
What workload it fits
These are useful for exposing data safely.
They do not make underlying data immutable, but they help communicate that consumers should not mutate the collection directly.
Where it appears
- published snapshots to UI or reporting layer
- service APIs exposing current run results
- read-only defect review results
- internal boundaries between processing and presentation layers
Common mistakes
- thinking read-only wrapper makes the underlying list thread-safe
- exposing a live mutable collection through
IReadOnlyList<T>while another thread still mutates it - using read-only interface as a substitute for ownership design
These are API design tools, not concurrency magic.
Immutable collections
Practical use
Immutable collections help when:
- many readers need stable snapshots
- mutation frequency is moderate
- consistency matters more than raw mutation speed
- you want to publish read models safely
Examples:
- machine state snapshot for UI
- run summary snapshot
- configuration sets
- defect classification summary published every 500 ms
Common mistakes
- using immutable structures in ultra-hot mutation paths
- publishing full copies too often
- creating excessive allocation pressure
- assuming “immutable” means “free”
Immutability is powerful, but it should be used intentionally.
Part 5 — Concurrent collections and thread-safe shared data
Why normal collections are dangerous under concurrency
Standard collections like List<T> and Dictionary<TKey,TValue> are not safe for concurrent writes, or read/write combinations without external coordination.
You may get:
- exceptions during enumeration
- corrupted assumptions
- lost updates
- stale values
- non-deterministic bugs that are hard to reproduce
That part is well known.
The more important lesson is this:
Even when individual operations are thread-safe, your workflow may still be wrong.
That is the real senior-level point.
ConcurrentDictionary<TKey,TValue>
When it helps
Good for:
- shared registries with independent key updates
- caches
- state maps where single-key operations dominate
- dedupe markers
- tracking latest value per key
Example:
- latest health status per subsystem
- active device connection per device ID
- last telemetry timestamp per sensor
When it is not enough
Suppose a machine state update requires:
- updating device state
- updating summary counters
- conditionally raising an alarm
- notifying UI projection
A concurrent dictionary can make one piece thread-safe, but not the whole multi-step workflow correct.
If multiple related structures must stay consistent, you still need ownership, serialization, or explicit coordination.
ConcurrentQueue<T>
When it helps
Great for producer-consumer flow:
- result ingestion
- alarm event ingestion
- persistence work staging
- command response handoff
When it is not enough
A queue safely holds items, but it does not solve:
- bounded growth
- drop policy
- backpressure
- downstream processing speed
- batching policy
- cancellation and completion semantics
So it is usually part of a pipeline design, not the whole design.
ConcurrentBag<T>
When it helps
Much less common in business or machine-control logic. It is useful when:
- ordering does not matter
- multiple threads collect independent results
- later aggregation is fine
Where it is less suitable
It is usually a poor fit for:
- UI lists
- time-ordered history
- deterministic processing
- queue-like workflows
- registries
In industrial desktop systems, ConcurrentBag<T> is often overused by people who just want “something thread-safe.”
Thread safety of operations vs correctness of workflow
This is one of the most important ideas in this whole topic.
Imagine this:
- Check whether alarm is already active
- If not, add active alarm
- Add alarm history entry
- Increment alarm counter
- Notify UI
Even if step 2 uses a thread-safe collection, the sequence as a whole is not automatically atomic or consistent.
Possible problems:
- duplicate history entries
- counter mismatch
- UI notified before state is consistent
- lost event if two threads race
A thread-safe container protects container internals. It does not automatically protect your business invariants.
Part 6 — Shared state strategies
This is where mature system design begins.
Minimize shared mutable state
The easiest shared state bug to fix is the one you never created.
If ten threads need to mutate the same collection, that is usually a design smell. It may be necessary sometimes, but often it means ownership is unclear.
The first design question should be: Can one component own this state and everyone else communicate with it instead?
That is often much safer.
Single-writer principle
A very strong production pattern is:
- many producers can submit events
- one logical processor owns mutation of the aggregate state
For example:
- multiple machine callbacks push events into a channel or queue
- one defect aggregator thread/service processes them in order
- only that aggregator mutates defect indexes, summaries, and bounded histories
This reduces:
- lock complexity
- race conditions
- inconsistent multi-structure updates
It also makes debugging easier because there is a clear mutation path.
Ownership of state
Every important state structure should have a clear owner.
Examples:
- defect aggregation state owned by
DefectAggregator - machine state registry owned by
MachineStateCoordinator - alarm active-set and recent history owned by
AlarmService - UI collection owned by a view-model update scheduler on UI thread
Ownership means:
- one component is responsible for writes
- others consume via messages, queries, or snapshots
- mutation rules are explicit
Without ownership, systems become “everyone touches everything,” which usually ends badly.
Message passing / pipelines instead of many threads mutating the same collection
This is often safer than locks everywhere.
Instead of:
- device callback thread mutates state
- polling thread mutates same state
- command completion thread mutates same state
- UI thread reads live state while mutations happen
You can do:
- all producers publish messages
- one coordinator consumes them
- coordinator updates authoritative state
- coordinator publishes snapshots or events for readers
This is conceptually similar to actor-style thinking, even if implemented with standard .NET tools.
Separate live mutable state from read-only projections
This is extremely useful.
Keep:
- internal mutable authoritative state for processing
- published read-only projections for UI/reporting
So the UI does not consume the hot internal structures directly. It consumes stable snapshots or carefully batched deltas.
That makes the system more predictable.
Why this is often safer than “just use locks everywhere”
Locks are not bad. But overusing them around large shared collections often creates:
- contention
- deadlock risk
- hard-to-reason code
- long lock duration
- accidental lock ordering bugs
- UI thread blocking
A single-writer or ownership-based design often produces simpler, safer behavior.
Part 7 — UI collections in WPF
ObservableCollection<T> and where it helps
ObservableCollection<T> is useful because it notifies WPF when items are added, removed, or the whole list changes.
That is helpful for:
- operator-facing lists
- alarm views
- review panels
- modest-rate dynamic updates
Why it is not a high-frequency real-time ingestion structure
ObservableCollection<T> is not designed to be your hot ingest buffer.
Problems:
- each item add raises collection changed
- UI thread must process each notification
- frequent change churn causes layout/render overhead
- background thread updates are unsafe unless marshaled
- massive update frequency overwhelms the dispatcher
So it is fine as a presentation collection, but not as your raw ingestion pipeline.
Batch changes before UI update
A much better pattern is:
- collect incoming results internally
- batch them every 100 ms, 250 ms, or other suitable interval
- apply batched updates on the UI thread
- optionally cap visible list size
That gives users a live feeling without turning the UI into a firehose victim.
Virtualization for large item lists
If defect lists, alarm lists, or thumbnail panels are large, UI virtualization becomes critical.
But virtualization only helps if:
- controls and templates are virtualization-friendly
- you are not triggering full resets too often
- your collection changes are controlled
- you are not creating excessive visual churn
Senior engineers know that collection design and WPF rendering behavior are tightly connected.
Separate processing collections from UI-bound collections
This is the key architectural rule.
Do not use one collection for all of these at once:
- ingestion
- storage
- indexing
- UI binding
- summary generation
That creates coupling and instability.
Instead:
- processing service owns internal state
- publishes safe projections
- UI receives batched view updates
- UI list is optimized for rendering, not ingestion
Part 8 — Windowing, bounded buffers, and recent history
Keep only the most recent N items
Many real system histories do not need unbounded in-memory storage.
Examples:
- last 100 alarms
- last 5,000 recent telemetry points for charting
- recent machine events for operator review
- last 200 defect thumbnails for live preview
A bounded buffer is often the right answer.
Ring-buffer style thinking
A ring buffer is useful when:
- only recent history matters
- fixed memory is desirable
- oldest items can be overwritten or evicted
This is common in real-time displays and rolling charts.
Bounded queues / capped history
Simple practical approaches:
- queue + dequeue when over capacity
- list with trimming
- custom circular buffer for hot paths
- bounded channel/queue for producer-consumer backpressure
The key is explicit retention policy.
Avoid unbounded memory growth
Every collection should have one of these stories:
- bounded in memory
- periodically drained
- persisted then trimmed
- short-lived local buffer
- immutable snapshot with controlled lifetime
If it has no growth story, it is a future incident.
Part 9 — Lookup, indexing, and multiple views of data
Why one collection is often not enough
This is a huge practical lesson.
One collection cannot usually optimize simultaneously for:
- ordered display
- fast lookup
- grouping
- summary
- bounded history
- UI friendliness
So real systems often maintain multiple structures.
Ordered and indexed views together
A common pattern:
List<Defect>or queue for arrival orderDictionary<Guid, Defect>for lookup by IDDictionary<Classification, int>for summary counts- maybe group index for region or class
That is duplication, yes. But it may be a smart trade-off.
Examples
Ordered live defect stream + fast lookup by defect ID
You want:
- operator sees defects in arrival order
- review workflow jumps to specific defect by ID
- duplicate result messages are ignored safely
This naturally suggests:
- ordered collection for display/history
- dictionary for identity lookup
- maybe hash set for dedupe
Grouping by defect type or region
If grouping is frequent, recomputing from raw list every time can be expensive.
It may be better to maintain:
- summary counters
- grouped projections
- periodic snapshot tables
Summary counters alongside raw results
If the dashboard constantly shows:
- total defects
- defects by class
- critical defects by region
then recalculating from the raw full list repeatedly may be wasteful. Maintaining summaries incrementally is often better.
Keeping these structures consistent
This is where ownership matters.
If one component owns all related structures and updates them together in one mutation path, consistency is manageable.
If many threads update many structures independently, consistency degrades quickly.
Multiple structures are not the problem. Multiple uncontrolled writers are the problem.
Part 10 — Immutability and snapshot-based designs
When immutable data helps
Immutable data is especially useful for:
- published UI state
- report models
- periodic run summaries
- machine state snapshots
- data shared broadly across readers
Readers love immutable data because it does not change under their feet.
Snapshot-based reads for UI and reporting
Instead of the UI reading a hot mutable dictionary directly, publish:
MachineStateSnapshotRunSummarySnapshotIReadOnlyList<DefectViewModelData>
These can be updated periodically or on meaningful change.
This reduces:
- cross-thread hazards
- partial-read inconsistencies
- accidental mutation by consumers
Reducing concurrency bugs by publishing read-only snapshots
This is a strong pattern:
- hot mutable state stays internal
- every so often, create a stable snapshot
- publish the snapshot to UI/readers
- readers render/query without touching internal state
This is especially good when read frequency is high and write ownership is centralized.
Costs of copying and allocations
The downside is obvious:
- copies cost CPU
- snapshots allocate memory
- frequent full-copy snapshotting can become expensive
So immutability is not free.
When it is worth it
Worth it when:
- consistency matters
- readers outnumber writers
- update rate is moderate
- data shape is not enormous
- UI simplicity matters
When it is too expensive
Maybe too expensive when:
- data volume is huge
- mutation is extremely frequent
- full snapshots happen too often
- you copy large object graphs unnecessarily
In those cases, partial snapshots, segmented models, or delta publishing may be better.
Part 11 — How we use this in .NET
Now let’s make this concrete.
Below are realistic patterns, not toy academic code.
Example 1: concurrent ingestion + single-threaded defect aggregation
using System.Collections.Concurrent;
using System.Collections.ObjectModel;
using System.Threading.Channels;
public sealed record Defect(
Guid Id,
string Classification,
string Region,
DateTime TimestampUtc,
string ThumbnailPath);
public sealed record DefectSummarySnapshot(
int TotalCount,
IReadOnlyDictionary<string, int> ByClassification);
public interface IUiDispatcher
{
Task InvokeAsync(Action action, CancellationToken cancellationToken = default);
}
public sealed class DefectAggregator
{
private readonly Channel<Defect> _input;
private readonly Dictionary<Guid, Defect> _byId = new();
private readonly Queue<Defect> _recentOrdered = new();
private readonly Dictionary<string, int> _countByClassification = new();
private readonly int _recentCapacity;
private readonly object _snapshotLock = new();
private DefectSummarySnapshot _latestSummary =
new(0, new ReadOnlyDictionary<string, int>(new Dictionary<string, int>()));
public DefectAggregator(int recentCapacity = 5000)
{
_recentCapacity = recentCapacity;
_input = Channel.CreateUnbounded<Defect>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false
});
}
public ValueTask EnqueueAsync(Defect defect, CancellationToken cancellationToken = default)
=> _input.Writer.WriteAsync(defect, cancellationToken);
public DefectSummarySnapshot GetLatestSummary()
{
lock (_snapshotLock)
{
return _latestSummary;
}
}
public IReadOnlyList<Defect> GetRecentSnapshot()
{
// Single writer owns mutation; snapshot copy for safe readers
return _recentOrdered.ToList().AsReadOnly();
}
public async Task RunAsync(CancellationToken cancellationToken)
{
await foreach (var defect in _input.Reader.ReadAllAsync(cancellationToken))
{
ProcessOne(defect);
}
}
private void ProcessOne(Defect defect)
{
// Deduplicate by defect ID
if (_byId.ContainsKey(defect.Id))
return;
_byId[defect.Id] = defect;
_recentOrdered.Enqueue(defect);
if (_countByClassification.TryGetValue(defect.Classification, out var count))
_countByClassification[defect.Classification] = count + 1;
else
_countByClassification[defect.Classification] = 1;
while (_recentOrdered.Count > _recentCapacity)
{
_recentOrdered.Dequeue();
}
PublishSummarySnapshot();
}
private void PublishSummarySnapshot()
{
var copy = new Dictionary<string, int>(_countByClassification);
var snapshot = new DefectSummarySnapshot(
TotalCount: _byId.Count,
ByClassification: new ReadOnlyDictionary<string, int>(copy));
lock (_snapshotLock)
{
_latestSummary = snapshot;
}
}
}Why this design is good
- many producers can enqueue defects concurrently
- only one reader mutates core defect state
- ID index, recent ordered view, and summary counts stay consistent
- readers do not directly touch hot mutable structures
- recent history is bounded
This is a far safer design than many threads mutating a shared ObservableCollection<Defect>.
Example 2: batching updates to a WPF UI collection
using System.Collections.ObjectModel;
using System.Threading.Channels;
public sealed class LiveDefectListPresenter
{
private readonly Channel<Defect> _uiInput;
private readonly IUiDispatcher _uiDispatcher;
private readonly ObservableCollection<Defect> _visibleDefects = new();
public ReadOnlyObservableCollection<Defect> VisibleDefects { get; }
private readonly int _maxVisibleItems;
private readonly TimeSpan _batchInterval;
public LiveDefectListPresenter(
IUiDispatcher uiDispatcher,
int maxVisibleItems = 500,
TimeSpan? batchInterval = null)
{
_uiDispatcher = uiDispatcher;
_maxVisibleItems = maxVisibleItems;
_batchInterval = batchInterval ?? TimeSpan.FromMilliseconds(200);
_uiInput = Channel.CreateUnbounded<Defect>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false
});
VisibleDefects = new ReadOnlyObservableCollection<Defect>(_visibleDefects);
}
public ValueTask PublishAsync(Defect defect, CancellationToken cancellationToken = default)
=> _uiInput.Writer.WriteAsync(defect, cancellationToken);
public async Task RunAsync(CancellationToken cancellationToken)
{
var buffer = new List<Defect>(256);
while (!cancellationToken.IsCancellationRequested)
{
var delayTask = Task.Delay(_batchInterval, cancellationToken);
while (_uiInput.Reader.TryRead(out var item))
{
buffer.Add(item);
}
if (buffer.Count == 0)
{
await delayTask;
continue;
}
var batch = buffer.ToArray();
buffer.Clear();
await _uiDispatcher.InvokeAsync(() =>
{
foreach (var defect in batch)
{
_visibleDefects.Add(defect);
}
while (_visibleDefects.Count > _maxVisibleItems)
{
_visibleDefects.RemoveAt(0);
}
}, cancellationToken);
}
}
}Why this design is good
- UI updates are batched
- only UI thread mutates
ObservableCollection - visible list is capped
- UI collection is presentation-focused, not ingestion-focused
This usually feels live enough to users while dramatically reducing UI churn.
Example 3: machine state coordinator with single ownership
public sealed record MachineStateUpdate(
string DeviceId,
bool IsConnected,
string Mode,
bool HasAlarm,
DateTime TimestampUtc);
public sealed record DeviceState(
string DeviceId,
bool IsConnected,
string Mode,
bool HasAlarm,
DateTime LastUpdatedUtc);
public sealed record MachineStateSnapshot(
IReadOnlyDictionary<string, DeviceState> Devices,
int ConnectedCount,
int AlarmedCount);
public sealed class MachineStateCoordinator
{
private readonly Channel<MachineStateUpdate> _updates =
Channel.CreateUnbounded<MachineStateUpdate>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false
});
private readonly Dictionary<string, DeviceState> _devices = new();
private readonly object _snapshotLock = new();
private MachineStateSnapshot _snapshot =
new(new ReadOnlyDictionary<string, DeviceState>(new Dictionary<string, DeviceState>()), 0, 0);
public ValueTask PublishAsync(MachineStateUpdate update, CancellationToken cancellationToken = default)
=> _updates.Writer.WriteAsync(update, cancellationToken);
public MachineStateSnapshot GetSnapshot()
{
lock (_snapshotLock)
{
return _snapshot;
}
}
public async Task RunAsync(CancellationToken cancellationToken)
{
await foreach (var update in _updates.Reader.ReadAllAsync(cancellationToken))
{
_devices[update.DeviceId] = new DeviceState(
update.DeviceId,
update.IsConnected,
update.Mode,
update.HasAlarm,
update.TimestampUtc);
PublishSnapshot();
}
}
private void PublishSnapshot()
{
var copy = new Dictionary<string, DeviceState>(_devices);
var connected = copy.Values.Count(x => x.IsConnected);
var alarmed = copy.Values.Count(x => x.HasAlarm);
var snapshot = new MachineStateSnapshot(
new ReadOnlyDictionary<string, DeviceState>(copy),
connected,
alarmed);
lock (_snapshotLock)
{
_snapshot = snapshot;
}
}
}Why this design is better than letting multiple threads mutate a shared dictionary
Because it gives:
- clear ownership
- consistent derived counters
- safe published snapshots
- predictable update path
- easier debugging
Example 4: bounded recent alarm history
public sealed record AlarmEvent(
string Code,
string Message,
DateTime TimestampUtc);
public sealed class RecentAlarmBuffer
{
private readonly int _capacity;
private readonly Queue<AlarmEvent> _items;
public RecentAlarmBuffer(int capacity)
{
_capacity = capacity;
_items = new Queue<AlarmEvent>(capacity);
}
public void Add(AlarmEvent alarm)
{
_items.Enqueue(alarm);
while (_items.Count > _capacity)
{
_items.Dequeue();
}
}
public IReadOnlyList<AlarmEvent> SnapshotNewestFirst()
{
return _items.Reverse().ToList().AsReadOnly();
}
}This is simple and often sufficient. If this becomes a hot path, a custom circular buffer may be better.
Part 12 — Common mistakes
Using List<T> everywhere by habit
This happens because List<T> is familiar and flexible.
Why it is dangerous:
- repeated linear scans become hidden bottlenecks
- uniqueness is not enforced
- lookup-heavy workloads suffer
- sharing it across threads becomes error-prone
Exposing mutable collections publicly
Example:
public List<Defect> Defects { get; } = new();This is an invitation to chaos. Anyone can mutate it. Ownership is gone.
Better:
- expose read-only views
- expose methods with clear mutation rules
- keep authoritative state private
Binding UI directly to hot internal collections
This causes:
- dispatcher overload
- thread affinity bugs
- collection churn
- accidental coupling between processing and rendering
Assuming ConcurrentDictionary solves all concurrency issues
It solves safe access to its internal structure. It does not solve:
- multi-step workflow correctness
- cross-collection consistency
- invariant protection
- ordering guarantees you may implicitly need
Keeping everything forever in memory
This is very common in systems that begin as prototypes.
Symptoms later:
- rising memory
- sluggish GC behavior
- slow startup of screens that re-read huge collections
- stale data no operator even needs
Updating multiple collections from many threads without ownership rules
This is how you get:
- summary mismatch
- missing indexes
- duplicate alarms
- views disagreeing with each other
Overusing locks around large collections
This often “works” at first, then becomes:
- contention hotspot
- UI freezes
- deadlock risk
- long unpredictable pauses
Choosing data structures without understanding workload
This is the root cause behind most of the others.
Part 13 — Performance and memory trade-offs
Append speed vs lookup speed
A list is usually nice for append and traversal. A dictionary is better for lookup. If you need both, you may need both.
That duplicates some references, but often gives better total behavior.
Memory overhead of indexes and multiple collections
Extra indexes cost memory. That is real.
But repeated recomputation also costs CPU and latency.
Senior engineers balance:
- memory cost of maintaining indexes
- runtime cost of recomputing from raw data
- consistency complexity
Thread-safety cost
Concurrency-safe structures and locking strategies have cost:
- extra coordination
- less predictable throughput
- more allocations in some designs
- more complex debugging
Sometimes the cheapest solution is not a more advanced collection. It is clearer ownership and fewer writers.
Batching vs immediacy
Immediate updates feel live, but they can overwhelm the UI. Batching improves stability, but adds slight latency.
Usually the right answer in operator UIs is controlled batching. Humans do not need 2,000 list item additions per second rendered individually.
Bounded buffers vs complete history retention
Keeping complete history in memory is usually not necessary. Persist old data and keep recent windows in memory.
Immutable snapshots vs allocation cost
Snapshots improve safety and simplify readers. But copying large structures too often can hurt.
The right answer depends on:
- structure size
- update rate
- reader count
- consistency needs
Part 14 — Debugging collection and state problems
Symptoms of bad shared-state design
Look for:
- occasional impossible states
- intermittent duplicates
- counts not matching detailed items
- UI showing stale state while backend logs show newer state
- bugs that disappear under debugger
- issues that only happen under load
These often point to race conditions or ownership problems.
Symptoms of wrong collection choices
Look for:
- CPU spikes from repeated scans
- slow filtering or grouping
- frequent GC pressure from rebuilding views
- memory growth from unbounded collections
- lock contention around a shared list or dictionary
UI lag from collection churn
Typical signs:
- scrolling stutter
- delayed selection changes
- input lag
- dispatcher queue buildup
- UI thread busy during heavy result flow
This often means the UI is coupled too closely to hot collections.
Race conditions caused by multi-thread mutation
Signs:
- inconsistent counts
- random missing items
- occasionally duplicated alarms
- “collection modified” exceptions
- order-dependent weirdness
Data inconsistency between views, indexes, and summaries
This is often caused by:
- multi-writer design
- partial updates
- no authoritative owner
- too many side effects spread across the codebase
How senior engineers investigate
They usually ask:
- What are the authoritative state structures?
- Who is allowed to mutate them?
- Are writes serialized or concurrent?
- What invariants should always hold?
- Which projections derive from which source?
- Are readers seeing live data or snapshots?
- Is memory bounded?
- Is UI update frequency controlled?
Then they instrument:
- collection sizes over time
- enqueue/dequeue rates
- UI batch sizes
- snapshot publish frequency
- lock duration/contended hotspots
- mismatch counters between indexes and summaries
And then they simplify. That is the key. Mature debugging often means redesigning ownership, not just patching with more locks.
Part 15 — Senior engineer mental model
Experienced engineers do not think of collections as just syntax.
They think of them as part of the system’s concurrency and data-flow architecture.
Their mental model looks something like this:
1. Choose collections based on access pattern
Not habit. Not textbook preference. Actual workload.
2. Treat ownership as more important than thread-safe APIs
A collection with one clear owner is often safer than a “shared thread-safe collection” touched by everyone.
3. Keep shared mutable state small
The more hot shared mutable state you have, the more fragile the system becomes.
4. Prefer predictable data flow
Producer → queue/channel → single processor → snapshot/projection → UI This is often better than multi-thread mutation of shared structures.
5. Separate internal processing state from published views
Hot mutable internals should not usually be your UI model.
6. Bound memory intentionally
Every long-running system needs a memory retention story.
7. Accept duplication when it improves behavior
One ordered collection plus one index plus one summary map is often the right answer.
8. Use immutability where it reduces whole classes of bugs
Especially for published state, snapshots, summaries, and broad fan-out reads.
9. Avoid cleverness
A simple owner-based design with boring collections often beats a clever lock-heavy design with “advanced” containers.
10. Optimize for correctness first, then throughput, then elegance
In real hardware systems, wrong and fast is worse than boring and correct.
A practical closing summary
In real .NET desktop systems, collection choice is not a small implementation detail. It is part of the architecture.
A strong design usually looks like this:
- choose structures by workload, not habit
- avoid many threads mutating the same state
- give important state a clear owner
- use queues/channels for ingestion
- use dictionaries for lookup
- use bounded histories for recent data
- use UI collections only for presentation
- publish snapshots or batched deltas to readers
- expose read-only views, not raw mutable internals
- keep related indexes and summaries updated in one owned mutation path
That is how senior engineers keep real-time, long-running, hardware-integrated .NET systems stable under real load.
If you want, next I can turn this into an interview-ready Q&A version with likely leadership questions and strong model answers.